use std::fs;
use std::io::Write;

use proptest::prelude::{any, prop, ProptestConfig};
use proptest::proptest;
use rstest::rstest;

use super::*;
use crate::tests::common::{
    arbitrary_json_object_strategy,
    lock_serial_test,
    make_config_from_json,
    option_string_strategy,
    with_cwd,
    FILE_STEM_STRING,
    GENERAL_STRING,
    IDENTIFIER_STRING,
};
use crate::tests::fixtures::{
    get_logs_for_current_thread,
    init_logging,
    test_data,
    TEMPORARY_DIRECTORY,
    TEMPORARY_DIRECTORY_TWO,
};

/// Recursively walk through a JSON value and convert all numbers to strings
/// formatted with a fixed number of significant digits (using scientific
/// notation).
fn normalize_numbers(value: &serde_json::Value, significant_digits: usize) -> serde_json::Value {
    match value {
        // For numbers, if we can get an f64, format it.
        serde_json::Value::Number(num) => {
            if let Some(float_value) = num.as_f64() {
                // Using scientific notation; the precision here is the number of digits
                // after the decimal point.
                let formatted = format!("{:.*e}", significant_digits, float_value);
                serde_json::Value::String(formatted)
            } else {
                value.clone()
            }
        },
        // For arrays, process each element.
        serde_json::Value::Array(arr) => serde_json::Value::Array(
            arr.iter()
                .map(|v| normalize_numbers(v, significant_digits))
                .collect(),
        ),
        // For objects, process each key-value pair.
        serde_json::Value::Object(map) => {
            let normalized_map: serde_json::Map<String, serde_json::Value> = map
                .iter()
                .map(|(k, v)| (k.clone(), normalize_numbers(v, significant_digits)))
                .collect();
            serde_json::Value::Object(normalized_map)
        },
        // Other types (Null, Bool, String) are left unchanged.
        _ => value.clone(),
    }
}

proptest! {
    #![proptest_config(ProptestConfig::with_cases(64))]

    #[test]
    fn test_load_does_not_panic(
        config_file_path in option_string_strategy(),
        tile_directory in option_string_strategy(),
    ) {
        // All tests that have log messages as side effect must be run in serial.
        let _guard = lock_serial_test();

        _ = load(config_file_path, tile_directory);
    }

    #[test]
    fn test_load_with_config_file_path_tile_directory_no_tile_directory_in_config(
        config_json in arbitrary_json_object_strategy(),
        tile_directory in IDENTIFIER_STRING,
    ) {
        // All tests that have log messages as side effect must be run in serial.
        let _guard = lock_serial_test();

        let mut config_file = tempfile::Builder::new().suffix(".json").tempfile()
            .expect("the temporary directory should be writable");
        let path = config_file.path().to_string_lossy().to_string();
        let json_string = serde_json::to_string(&config_json).expect("the Value should serialize");
        config_file.write_all(json_string.as_bytes()).expect("the file should be writable");

        let mut expected_config_json = config_json.clone();
        expected_config_json
            .as_object_mut()
            .expect("the Value should be a mutable object")
            .insert("tile_directory".into(), tile_directory.clone().into());
        expected_config_json = normalize_numbers(&expected_config_json, 6);
        let expected_config_json_string = serde_json::to_string(&expected_config_json)
            .expect("the Value should serialize");

        let result_config = load(Some(path), Some(tile_directory.clone()))
            .expect("the config file should load");

        let mut actual_config_json: serde_json::Value = result_config.try_deserialize()?;
        actual_config_json = normalize_numbers(&actual_config_json, 6);
        let actual_config_json_string = serde_json::to_string(&actual_config_json)
            .expect("the Value should serialize");
        assert_eq!(expected_config_json_string, actual_config_json_string);
    }

    #[test]
    fn test_load_with_config_file_path_tile_directory_tile_directory_in_config(
        initial_config_json in arbitrary_json_object_strategy(),
        config_file_tile_directory in IDENTIFIER_STRING,
        tile_directory in IDENTIFIER_STRING,
    ) {
        // All tests that have log messages as side effect must be run in serial.
        let _guard = lock_serial_test();

        let mut config_json = initial_config_json.clone();
        config_json
            .as_object_mut()
            .expect("the Value should be a mutable object")
            .insert("tile_directory".into(), config_file_tile_directory.into());

        let mut config_file = tempfile::Builder::new().suffix(".json").tempfile()
            .expect("the temporary directory should be writable");
        let path = config_file.path().to_string_lossy().to_string();
        let json_string = serde_json::to_string(&config_json).expect("the Value should serialize");
        config_file.write_all(json_string.as_bytes()).expect("the file should be writable");

        let mut expected_config_json = initial_config_json.clone();
        expected_config_json
            .as_object_mut()
            .expect("the Value should be a mutable object")
            .insert("tile_directory".into(), tile_directory.clone().into());
        expected_config_json = normalize_numbers(&expected_config_json, 6);
        let expected_config_json_string = serde_json::to_string(&expected_config_json)
            .expect("the Value should serialize");

        let result_config = load(Some(path), Some(tile_directory.clone()))
            .expect("the config file should load");

        let mut actual_config_json: serde_json::Value = result_config.try_deserialize()?;
        actual_config_json = normalize_numbers(&actual_config_json, 6);
        let actual_config_json_string = serde_json::to_string(&actual_config_json)
            .expect("the Value should serialize");
        assert_eq!(expected_config_json_string, actual_config_json_string);
    }

    #[test]
    fn test_load_with_config_file_path_no_tile_directory_tile_directory_in_config(
        mut config_json in arbitrary_json_object_strategy(),
        tile_directory in IDENTIFIER_STRING,
    ) {
        // All tests that have log messages as side effect must be run in serial.
        let _guard = lock_serial_test();

        config_json
            .as_object_mut()
            .expect("the Value should be a mutable object")
            .insert("tile_directory".into(), tile_directory.clone().into());

        let mut config_file = tempfile::Builder::new().suffix(".json").tempfile()
            .expect("the temporary directory should be writable");
        let path = config_file.path().to_string_lossy().to_string();
        let json_string = serde_json::to_string(&config_json).expect("the Value should serialize");
        config_file.write_all(json_string.as_bytes()).expect("the file should be writable");

        let mut expected_config_json = config_json.clone();
        expected_config_json = normalize_numbers(&expected_config_json, 6);
        let expected_config_json_string = serde_json::to_string(&expected_config_json)
            .expect("the Value should serialize");

        let result_config = load(Some(path), None)
            .expect("the config file should load");

        let mut actual_config_json: serde_json::Value = result_config.try_deserialize()?;
        actual_config_json = normalize_numbers(&actual_config_json, 6);
        let actual_config_json_string = serde_json::to_string(&actual_config_json)
            .expect("the Value should serialize");
        assert_eq!(expected_config_json_string, actual_config_json_string);
    }

    #[test]
    fn test_load_with_config_file_path_no_tile_directory(
        config_json in arbitrary_json_object_strategy(), file_stem in FILE_STEM_STRING
    ) {
        // All tests that have log messages as side effect must be run in serial.
        let _guard = lock_serial_test();

        let directory = tempfile::tempdir().expect("the filesystem should be accessible");
        let config_file_name = format!("{}.json", file_stem);
        let config_file_path = directory.path().join(config_file_name);
        let json_string = serde_json::to_string(&config_json).expect("the Value should serialize");
        fs::write(&config_file_path, json_string.as_bytes())
            .expect("The config file should writable");

        let mut expected_config_json = config_json.clone();
        expected_config_json = normalize_numbers(&expected_config_json, 6);
        expected_config_json
            .as_object_mut()
            .expect("the Value should be a mutable object")
            .insert(
                "tile_directory".into(),
                config_file_path
                    .parent()
                    .expect("file should have a parent directory")
                    .to_str()
                    .expect("path should be valid UTF-8")
                    .into(),
            );
        let expected_config_json_string = serde_json::to_string(&expected_config_json)
            .expect("the Value should serialize");

        let result_config = load(Some(config_file_path.to_string_lossy().to_string()), None)
            .expect("the config file should load");

        let mut actual_config_json: serde_json::Value = result_config.try_deserialize()?;
        actual_config_json = normalize_numbers(&actual_config_json, 6);
        let actual_config_json_string = serde_json::to_string(&actual_config_json)
            .expect("the Value should serialize");
        assert_eq!(expected_config_json_string, actual_config_json_string);
    }

    #[test]
    fn test_load_with_invalid_config_file_path(
        config_file_path in GENERAL_STRING,
        tile_directory in option_string_strategy(),
    ) {
        // All tests that have log messages as side effect must be run in serial.
        let _guard = lock_serial_test();

        let result = load(
            Some(config_file_path.clone()),
            tile_directory
        );
        assert_eq!(
            result.unwrap_err().to_string(),
            format!("configuration file \"{}\" not found", config_file_path),
        );
    }

    #[test]
    fn test_load_with_invalid_config_file(
        config_file_content in prop::collection::vec(any::<u8>(), 200..2048),
        tile_directory in option_string_strategy(),
    ) {
        // All tests that have log messages as side effect must be run in serial.
        let _guard = lock_serial_test();

        let mut config_file = tempfile::Builder::new().suffix(".json").tempfile()
            .expect("the temporary directory should be writable");
        let path = config_file.path().to_string_lossy().to_string();
        config_file.write_all(&config_file_content).expect("the file should be writable");

        let result = load(Some(path), tile_directory);
        assert_eq!(result.unwrap_err().to_string(), "stream did not contain valid UTF-8");
    }
}

#[test]
fn test_load_with_no_config_file_path_no_tile_directory_passed_nonexistent_cwd() {
    let directory = tempfile::tempdir().expect("the filesystem should be accessible");
    with_cwd(directory.path(), || {
        // Delete current directory before running TileList::create() to induce an
        // error.
        fs::remove_dir(directory.path()).expect("the temporary directory should be accessible");

        let result = load(None, None);

        let error = result.expect_err("Expected an Err, got Ok");

        assert_eq!(
            error.to_string(),
            "the current working directory could not be accessed: No such file or directory (os \
             error 2)"
        );
    });
}

#[rstest]
// region no config_file_path passed, no tile_directory passed
#[case::no_config_file_path_passed_no_tile_directory_passed(
    None,
    None,
    make_config_from_json(
        &format!(
            "{{\"tile_directory\": \"{}\"}}",
            TEMPORARY_DIRECTORY.path().to_string_lossy(),
        )
    ),
    vec![
        format!(
            "image_tiler::configuration: INFO ⸙ No tile directory provided; assuming current \
            working directory {}",
            TEMPORARY_DIRECTORY.path().to_string_lossy(),
        ),
    ],
    None,
    TEMPORARY_DIRECTORY.path().to_string_lossy().to_string(),
)]
// endregion
// region no config_file_path passed, tile_directory passed
#[case::no_config_file_path_passed_tile_directory_passed(
    None,
    Some(TEMPORARY_DIRECTORY_TWO.path().to_string_lossy().to_string()),
    make_config_from_json(
        &format!(
            "{{\"tile_directory\": \"{}\"}}",
            TEMPORARY_DIRECTORY_TWO.path().to_string_lossy(),
        )
    ),
    vec![],
    None,
    TEMPORARY_DIRECTORY.path().to_string_lossy().to_string(),
)]
// endregion
// region config_file_path with tile_directory passed, tile_directory passed
#[case::config_file_path_with_tile_directory_passed_tile_directory_passed(
    Some(test_data("empty/invalid_second.yaml")),
    Some(TEMPORARY_DIRECTORY_TWO.path().to_string_lossy().to_string()),
    make_config_from_json(
        &format!(
            "{{
                \"tile_directory\": \"{}\",
                \"tiles\": [
                    {{
                        \"connectors\": {{\
                            \"east\": \"a\", \"north\": \"a\", \"south\": \"a\", \"west\": \"a\"
                        }},
                        \"path\": \"text_tile_empty\"
                    }},
                    {{
                        \"connectors\": {{\
                            \"east\": \"a\", \"north\": \"a\", \"south\": \"a\", \"west\": \"a\"
                        }},
                        \"path\": \"text_tile_two_rows_three_cols_invalid\"
                    }}

                ]
            }}",
            TEMPORARY_DIRECTORY_TWO.path().to_string_lossy(),
        )
    ),
    vec![],
    None,
    TEMPORARY_DIRECTORY.path().to_string_lossy().to_string(),
)]
// endregion
// region config_file_path invalid w.ron passed, valid tile_directory passed, no tiles
#[case::config_file_path_w_ron_passed_valid_tile_directory_passed_no_tiles(
    Some(test_data("empty/w.ron")),
    Some(TEMPORARY_DIRECTORY_TWO.path().to_string_lossy().to_string()),
    make_config_from_json("{}"),
    vec![],
    Some(Error::ValueError("1:1: Unexpected end of RON in ../empty/w.ron".into())),
    test_data("four_raster_image_tiles"),
)]
// endregion
fn test_load(
    #[case] config_file_path: Option<String>,
    #[case] tile_directory: Option<String>,
    #[case] expected_config: Config,
    #[case] expected_log_messages: Vec<String>,
    #[case] expected_error: Option<Error>,
    #[case] cwd: String,
) {
    let expected_config_json: serde_json::Value = expected_config
        .try_deserialize()
        .expect("configuration should be deserializable to json");

    with_cwd(cwd, || {
        init_logging();
        let config = load(config_file_path.clone(), tile_directory.clone());

        let log_messages = get_logs_for_current_thread();
        assert_eq!(expected_log_messages, log_messages);

        if let Some(expected_error) = &expected_error {
            let error = config.expect_err("expected error, got Ok");
            assert_eq!(expected_error, &error);
        } else {
            let actual_config_json: serde_json::Value = config
                .expect("expected Ok, got Err")
                .try_deserialize()
                .expect("configuration should be deserializable to json");
            assert_eq!(actual_config_json, expected_config_json);
        }
    })
}
